查看原文
其他

想做更深入的加载优化?剖析Cocos引擎底层架构后,乐府大佬交出「90分答案」

乐府 - 小学生 COCOS 2022-06-10

引言:无论是对引擎研发团队或是游戏开发团队来说,优化的重要性都不言而喻。本次,来自乐府互娱「乐府小学生」在实际项目开发中,通过修改引擎源码实现了更加深入的加载优化。


游戏江湖上曾流传过一句名言:“三流的游戏做功能,二流的游戏做表现,一流的游戏做优化。”虽然有点扯,但并非全无道理,至少说明了优化在做游戏中的重要性。本文将结合我参与的项目实例,分享我们是如何站在 Cocos Creator 的肩膀上”做更深入的加载优化。


本文所用引擎版本为 Cocos Creator 2.4.6。


一、原音重现


Cocos Creator 的加载流程



以上是 loadRes 的加载流程,其中的关键步骤说明如下:

  • url tranform:主要是将工程路径地址 /uuid 转换成对应的实际资源地址。

  • load res:主要是文件的 IO 过程,并把加载后的资源转成对应的 Json 对象或二进制数组。

  • parse:主要是把加载到的资源解析成对应的对象。

  • depends:获取当前资源的依赖,然后继续调用开始的步骤加载。


剖析 Prefab 的加载流



以上流程左侧清晰地展示了 Cocos Creator 的加载管线,从引擎源码获知从 url transform 至 depends 前的流程都可以插入自定义管线,具备较好的灵活性和扩展性。


右侧部分为 cc.Spriteframe 资源的加载流程,这里为了展示区别,我们将其与 Cocos2d-x 中的 CCSprite 加载进行对比:



不难看出在 Cocos Creator 中创建一个 Sprite 会比 Cocos2d-x 时多两个流程。而从 IO 次数上对比,单张贴图的加载上 Cocos Creator 比 Cocos2d-x 多2次 IO(SpriteFrame 配置和 Texture2d 配置)。那么这两个配置是否是必要的?


答案还得从 Cocos Creator 本身的特性说起:


1、SpriteFrame 配置文件(下文简称【配置1】):一个独立的 json 文件,用来存储一九宫,以及纹理大小偏移等信息。可以使纹理自定义修改九宫图等更灵活。对应的就是下面属性面板中的信息:



TIPS :Cocos2d-x 时期的配置是保存在对应 ui 编辑器生成的配置文件里,其他没有被界面引用的资源,需要在代码中指定配置。


2、Texture2d 的配置(下文简称【配置2】):主要定义纹理相关属性。



上图显示,有两个属性配置(WarpMode, FilterMode)会使我们使用图片和修改配置上更灵活。


综上,Cocos Creator 加载流程多出的两个配置是必要的。那么在效率上是否有优化空间?


二、选 A 还是选 C


官方的构建发布界面上有关于贴图配置的合并选项:



官方文档的解释如下:


内联所有 SpriteFrame

自动合并资源时,将所有 SpriteFrame 与被依赖的资源合并到同一个包中。建议网页平台开启,启用后会略微增大总包体,多消耗一点点网络流量,但是能显著减少网络请求数量。建议原生平台关闭,因为会增大热更新时的体积。

合并图集中的 SpriteFrame

将图集中的全部 SpriteFrame 合并到同一个包中。默认关闭,启用后能够减少热更新时需要下载的 SpriteFrame 文件数量,但如果图集中的 SpriteFrame 数量很多,则可能会稍微延长原生平台上的启动时间。

如果项目中图集较多,有可能会导致 project.manifest 文件过大,建议勾选该项来减小 project.manifest 的体积。

注意:在热更新时,需要确保新旧项目中该功能的开启/关闭状态保持一致,否则会导致热更新之后出现资源引用错误的情况。


通俗的解释就是:

  • 内联:将 SpriteFrame 对应的 json 文件【配置1】合并到了 prefab 中。

  • 合并图集:把自动图集中所有 SpriteFrame 合并到同一个文件中,类似 TexturePacker 的 plist 文件。


各自的优缺点,在官方文档中有详细描述。那么有没有一种解决方案,即能提高加载效率,又不影响启动速度呢?


三、90分答案


本项目所采用的解决办法是:

  1. 合并所有的 SpriteFrame 的配置,减少 IO。

  2. 将合并后的配置转成二进制文件,加快启动速度。


SpriteFrame 配置优化


下面是 SpriteFrame 配置信息,只有 "e8Ueib+qJEhL6mXAHdnwbi"(依赖)和中间的数据区是不同的:

[
  1,
  [
    "e8Ueib+qJEhL6mXAHdnwbi"
  ],
  [
    "_textureSetter"
  ],
  [
    "cc.SpriteFrame"
  ],
  0,
  [
    {
      "name""default_btn_normal",
      "rect": [
        0,
        0,
        40,
        40
      ],
      "offset": [
        0,
        0
      ],
      "originalSize": [
        40,
        40
      ],
      "capInsets": [
        12,
        12,
        12,
        12
      ]
    }
  ],
  [
    0
  ],
  0,
  [
    0
  ],
  [
    0
  ],
  [
    0
  ]
]


  • 解决方案


1、相同的部分作为模板定义在代码中(减少冗余数据),提取所有的差异部分合并到同一个文件中,组成如下配置:

{[
{
      "name""default_btn_normal",
      "rect": [
        0,
        0,
        40,
        40
      ],
      "offset": [
        0,
        0
      ],
      "originalSize": [
        40,
        40
      ],
      "capInsets": [
        12,
        12,
        12,
        12
      ],
      "depend""e8Ueib+qJEhL6mXAHdnwbi" // 额外加入字段
 },
 ...
 ],
 [uuid1,uuid2,...] // 额外加入字段为文件的uuid,与上面的顺序保持一致
}


2、将文件转成二进制格式,这样可以有效降低文件大小,提高初始化速度,并且减少数据和字段冗余。二进制方案推荐使用 flatbuffers,具体使用方法可以参考网上教程或官方文档。


3、接管游戏下载流程,保证文件正常读取。


3.1 接管 IO:修改 builtin/jsb-adapter/engine/ jsb-fs-utils.js 文件,添加如下:

setJsonReadHandler(handler) {
        fsUtils._customJsonLoadHandler = handler
    },

    readJson (filePath, onComplete) {
        let jsonLoadhandler = fsUtils._customJsonLoadHandler
        if (jsonLoadhandler && jsonLoadhandler(filePath, onComplete)) {
            return
        }
        fsUtils.readFile(filePath, 'utf8'function (err, text) {
            var out = null;
            if (!err) {
                try {
                    out = JSON.parse(text);
                }
                catch (e) {
                    cc.warn(`Read json failed: path: ${filePath} message: ${e.message}`);
                    err = new Error(e.message);
                }
            }
            onComplete && onComplete(err, out);
        });
    },

注:这里是原生端的修改部分,网页端可以通过自定义加载管线的方式处理


3.2 数据还原:通过模板数据和二进制数据对 SpriteFrame 格式做还原,是这里的数据区存为 flatbuffers 对象即可,用到的地方再去解析:

[
  1,
  [
    "e8Ueib+qJEhL6mXAHdnwbi"
  ],
  [
    "_textureSetter"
  ],
  [
    "cc.SpriteFrame"
  ],
  0,
  [
    // flatbuffer对象
  ],
  [
    0
  ],
  0,
  [
    0
  ],
  [
    0
  ],
  [
    0
  ]
]


3.3 修改 CCSpriteframe.js 文件,修改解析:

_deserialize: function (data, handle) {
        if (!CC_EDITOR && data.bb) {
            this._deserializeWithFlatbuffers(data);
            return;
        }
        ...
}


Texture2d 配置优化


Texture2d 的配置如下:

[
  1,
  0,
  0,
  [
    "cc.Texture2D"
  ],
  0,
  [
    "0,9729,9729,33071,33071,0,0,1",
    -1
  ],
  [
    0
  ],
  0,
  [],
  [],
  []
]


与 SpriteFrame 配置相比,Texture2d 的配置简单多了,里面的属性值主要是与属性面板和文件扩展名有关。如果图片的属性都是默认的,并且扩展名是相同的情况下,Texture2d 配置是完全相同的,即项目中若有200张图片资源,那200个图片的配置文件就是完全相同的。


  • 解决方案


通过 md5 比对所有的 Texture2d 配置文件,提取不同的文件,生成对应的配置映射以便快速读取。以我当前的项目为例:有9000+图片资源,最终比对下来也就只有5种类型,所以就直接把这5种配置在代码中写死,同样在上面的接管流程中返回对应的配置信息。


优化前后,iphone6 测试的加载速度提升了43%左右:



Texture2d 加载流程优化


原生的纹理加载的流程,把纹理数据转换成 ArrayBuffer 传给 js,然后在 js 层再重新组装返回 C++ 层,这里存在两次数据传递的过程。流程如下:



优化的方向:在加载完成后,原生层一步到位。直接创建成 Texture2d 对象返回,减少中间的数据传入过程。修改后的流程如下(红框部分为省略的部分):



注:修改为如上流程后,原生端的动态合图将无法使用。但是大多数的原生开发都会使用压缩纹理,并且压缩纹理也是不支持动态合图的。所以动态合图的问题大家完全可以忽略。


  • 代码修改如下:


C++ 部分:

cocos2d-x/cocos/scripting/js-bindings/manual/jsb_global.cpp

...
if (loadSucceed)
{
  se::Object* retObj = se::Object::createPlainObject();
  retObj->root();
  refs.push_back(retObj);
  cocos2d::renderer::Texture2D* cobj = new (std::nothrow) cocos2d::renderer::Texture2D();
  auto obj = se::Object::createObjectWithClass(__jsb_cocos2d_renderer_Texture2D_class);
  obj->setPrivateData(cobj);

  cocos2d::renderer::Texture::Options options;
  options.bpp = imgInfo->bpp;
  options.width = imgInfo->width;
  options.height = imgInfo->height;

  options.glType = imgInfo->type;
  options.glFormat = imgInfo->glFormat;
  options.glInternalFormat = imgInfo->glInternalFormat;

  options.compressed = imgInfo->compressed;
  options.hasMipmap = false;
  options.premultiplyAlpha = imgInfo->hasPremultipliedAlpha;

  std::vector<cocos2d::renderer::Texture::Image> images;
  cocos2d::renderer::Texture::Image image;
  image.data = imgInfo->data;
  image.length = imgInfo->length;
  images.push_back(image);
  options.images = images;

  cobj->initWithOptions(options);

  retObj->setProperty("texture", se::Value(obj));
  retObj->setProperty("width", se::Value(imgInfo->width));
  retObj->setProperty("height", se::Value(imgInfo->height));
  seArgs.push_back(se::Value(retObj));

  imgInfo = nullptr;
}
...


JS 代码修改:

builtin/jsb-adapter/builtin/jsb-adapter/HTMLImageElement.js

set src(src) {
 this._src = src;
 jsb.loadImage(src, (info) => {

    if (!info) {

        this._data = null;

        return;

    } else if (info && info.errorMsg) {

        this._data = null;

        var event = new Event('error');

        this.dispatchEvent(event);

        return;

    }

    this.width = this.naturalWidth = info.width;

    this.height = this.naturalHeight = info.height;

   if (info.texture) {

        info.texture._ctor()

        this.texture = info.texture

    }

    else {

         ...

    }

    this.complete = true;

    var event = new Event('load');

    this.dispatchEvent(event);

});

}

engine/cocos2d/core/assets/CCTexture.js

_nativeAsset: {
    get () {
        // maybe returned to pool in webgl
        return this._image;
    },
    set (data) {
        if (data.texture) {
            this.initWithTexture(data.texture, data.width, data.height)
            return
        }
        ...
    }
},
// 添加如下函数
initWithTexture (texture, pixelsWidth, pixelsHeight) {
    this._texture = texture
    this.width = pixelsWidth;
    this.height = pixelsHeight;

    // 通知原生端更新配置,如果没有修改texture属性的,代码基本跑不到。
    // _updateNative标志在当前对象序列化的时候记录如果配置中的信息和默认值不一致时为true
    if (this._updateNative) {
        var opts = _getSharedOptions();
        opts.minFilter = FilterIndex[this._minFilter];
        opts.magFilter = FilterIndex[this._magFilter];
        opts.wrapS = this._wrapS;
        opts.wrapT = this._wrapT;
        texture.update(opts, true// 这里需要在原生端添加一个简易的更新函数。就拿原来的更新函数提出纹理数据就好了,这里就不贴了。
    }
    this.loaded = true;
    this.emit("load");
    return true;
},


优化前后,iphone6 测试的加载速度提升了 12%-15% 左右:



以上统计的是 Prefab 加载前后的数据,包含了异步加载纹理的时间,所以会有时间较长的情况,但是同步耗时的地方基本没了,并且在 iphone6 上已经感受不到明显的卡顿了。


四、附加题


spine 加载优化


由于 spine 的骨骼动画是在原生端单独加载的,所以在 js 加载的时候可以移除 spine 骨骼加载,减少一次 IO。


  • 修改文件如下:deserialize.js

function deserialize (json, options) {
    ...    
    // 不是原生端或者不是骨骼文件,spine原生端不加载骨骼文件
    asset._native && (asset.__nativeDepend__ = !CC_JSB || !(asset instanceof sp.SkeletonData));

    pool.put(tdInfo);
    return asset;
}


路径搜索(fullPathForFilename)


由于第一次路径填充的时候,需要从所有的路径里去查找。从小米5上测试发现每次路径检查需要消耗 2ms 左右。正常我们会有两个路径:一个更新路径,一个是当前包路径。所以小米5上一个文件检索至少要 4ms+。


  • 解决方案:

自己生成一个路径映射表。因为打包和更新的时候文件有哪些都是确定的。这样就可以使文件查找的速度降到 50μs 以下。




本文主要是想分享一个加载优化的思路和方向给大家,感兴趣的小伙伴可以点击文末【阅读原文】前往论坛专贴一起交流讨论:

https://forum.cocos.org/t/topic/134363


往期精彩

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存